ueditor源代码重点难点分析 | 您所在的位置:网站首页 › ueditor html查看源码 › ueditor源代码重点难点分析 |
网上好像几乎没有研究ueditor源码的文章,原因可能是ueditor源码太复杂了,接近浏览器代码和word/excel源码。本文分析ueditor源码整体流程逻辑以及重点难点细节。 首先,编辑器是如何实现输入的?本人开始始终不得其解,在源码找不到输入事件绑定的处理函数,后来在白云峰同学的提醒下才顿悟,整个iframe网页就相当于是一个元素:
页面调用ueditor: // iframe的container元素 var editor = UE.getEditor('editor'); 多次调用可以多实例运行,每个实例都是单独的,编辑器实例保存在UE实例中,从UE.instants[]也可以获取到每个编辑器实例,0就是第一个实例,以此类推,因此可以不用变量引用编辑器实例: UE.getEditor('editor'); setTimeout(function(){ UE.instants.ueditorInstant0.setContent('欢迎使用编辑器'); },1000); 执行ueditor文件之后产生三个全局对象:UEDITORUI - 所有工具按钮插件的apiUE - api入口UEDITOR_CONFIG - 配置数据 先看ueditor的全局api接口: window.UE = baidu.editor = window.UE || {}; // UE实例提供ueditor的入口接口,也就是api入口,调用UE的方法才创建真正的编辑器实例 var Editor = UE.Editor = function (options) { // 这是编辑器构造函数 /* 尝试异步加载后台配置 */ me.loadServerConfig(); //所谓异步加载就是用js构造[\\r\\t\\n'+(ignoreBlank?'':' ')+']*','g'), function(a,b){ // 正则匹配替换 toHtml:function (formatter) { var arr = []; nodeToHtml(this, arr, formatter, 0); function nodeToHtml(node, arr, formatter, current) { switch (node.type) { case 'root': for (var i = 0, ci; ci = node.children[i++];) { nodeToHtml(ci, arr, formatter, current) // 递归子节点 return arr.join('') 可见获取编辑器的内容就是获取iframe网页的内容body.innerHTML现成的html代码,很简单,但解析处理非常复杂,有内置过滤规则处理,有点类似框架的template/vnode解析处理,要递归解析处理所有的子节点。
编辑器头部是工具按钮,都是以插件形式实现的,下面以点击“模板”工具按钮为例分析ueditor的工具按钮插件是如何实现的。 点击模板(template)按钮是插入模板,会显示一个弹窗对话框: 里面是一个iframe加载一个网页: 这个网页就是一个列表,选择之后关闭弹窗,把选择的内容插入编辑器中。 注意dialog会话弹窗层iframe不在编辑器层iframe里面,而是在当前网页里面,当前网页有几个container容器,其中一个放编辑器iframe,一个放dialog iframe,还有编辑器的头部/底部都是单独的容器,都不在编辑器容器里面。凡是跨iframe都有传递数据问题,因此dialog弹窗也有传递数据问题,后面会分析它如何传递数据。
iframe网页会执行internal.js建立环境: dialog = parent.$EDITORUI[window.frameElement.id.replace( /_iframe$/, '' )]; // dialog实例是从父网页获取的,含父网页中编辑器实例 editor = dialog.editor; //当前打开dialog的编辑器实例 dialog.onok = function () { me.execCommand( "template", obj ); //执行template命令, obj.html就是选取的template代码,me是editor实例 //重写execCommand命令,用于处理框选时的处理 var oldExecCommand = me.execCommand; me.execCommand = function (cmd, datatat) { result = oldExecCommand.apply(me, arguments); // oldExecCommand代码如下 execCommand: function (cmdName) { result = this._callCmdFn('execCommand', arguments); _callCmdFn: function (fnName, args) { return cmdFn.apply(this, args); UE.plugins['template'] = function () { UE.commands['template'] = { execCommand:function (cmd, obj) { // cmd=template obj.html && this.execCommand("inserthtml", obj.html);//再次递归editor实例的execCommand,但这次是执行inserthtml命令,会执行到以下代码 UE.commands['inserthtml'] = { execCommand: function (command,html,notNeedFilter){ // 把选取的template插入编辑器网页 range = me.selection.getRange(); // 获取编辑器中dialog会话弹窗之前光标选中的区域,也就是template插入的位置 getRange:function () { var range = new baidu.editor.dom.Range( me.document ); // 创建range对象,数据结构与js原生selection对象一样 var sel = me.getNative(); // 调window.getSelection()返回点击选取的节点数据,此时已经点击工具按钮,之前点击选取的光标状态已经不在,获取不到点击选取数据,单步看获取的数据是空的,因此要使用之前保存的selection数据。 getNative:function () { return domUtils.getWindow( doc ).getSelection(); }, if ( this._bakRange && domUtils.inDoc( this._bakRange.startContainer, this.document ) ){ return this._bakRange; //之前在编辑器点击选取触发执行getNative保存的selection数据,container=text,offset=1(没有意义) } } //如果当前位置选中了fillchar要干掉,要不会产生空行 if(range.inFillChar()){ // 插入第一个子节点第一次执行时range是#text填充符 child = range.startContainer; // 是text节点 if(domUtils.isFillChar(child)){ // 插入第一个子节点第一次执行时start container是#text填充符 range.setStartBefore(child).collapse(true); //设置container=p,offset=0(#text节点在p中的index),collapse(折叠)意思是设置end=start,如果选中一段再插入就有start container/end container问题。 setStartBefore(child)意思就是要插入到child之前,但要获取child在父节点中的offset,插入时在父节点中按offset再获取child,再插入到child之前 domUtils.remove(child); // 删除#text节点,那么在p节点内部offset=0是br节点 }else if(domUtils.isFillChar(child,true)){ child.nodeValue = child.nodeValue.replace(fillCharReg,''); range.startOffset--; range.collapsed && range.collapse(true) } } while ( child = div.firstChild ) { // 递归循环div的子节点把div的子节点一个一个插入(div不插入),如果把div整个插入,就多了div层,其实可以用frag,把frag整个插入即可,但插入第一个子节点时start container是p节点,插入之后要调整start container=body,从第二个子节点开始都是插入body,所以还不能整个一次插入,挺复杂的。 if(hadBreak){ //第一次执行时hadBreak=0,不执行这段,之后再执行时hadBreak=1,会执行这段,hadBreak表示已经切割container元素, var p = me.document.createElement('p'); while(child && (child.nodeType == 3 || !dtd.$block[child.tagName])){ // 如果要插入的节点是#text节点则套一层p,为何? nextNode = child.nextSibling; p.appendChild(child); //child是引用div子节点,那么div子节点插入到p就从div移动到p,div中已经没有child,所以循环n次之后div就变空了 child = nextNode; } // 如果是文本节点就插入到p元素里面,如果有一批文本节点就循环全部插入到p元素里面,再把p做为child节点 if(p.firstChild){ child = p // } } //第n次执行时插入的node是内容节点,不会外套一层p,这段不起作用 range.insertNode( child ); // 第一次插入子节点时把node插入到 的之前,之后插入到body里面 之前 insertNode:function (node) { // 编辑器头尾有空格文本节点,第一次执行时是插入填充节点,第n次执行时是插入内容节点 var first = node, length = 1; var start = this.startContainer, //单步看插入第二个子节点时,是,offset是2指向 offset = this.startOffset; var nextNode = start.childNodes[ offset ]; // body[2]=if (nextNode) { start.insertBefore(node, nextNode); } else { start.appendChild(node); } return this.setStartBefore(first); // 第一个子节点插入之后,根据第一个子节点调整插入指针,第一个子节点此时还在p中, range.startContainer = p range.startOffset = 0 (node在p中的index) 此时p变为 node nextNode = child.nextSibling; // child插入之后的nextsibling就是原来的占位节点br if ( !hadBreak && child.nodeType == domUtils.NODE_ELEMENT && domUtils.isBlockElm( child ) ){ // 第一次循环插入子节点时hadBreak=0会执行一次 parent = domUtils.findParent( child,function ( node ){ return domUtils.isBlockElm( node ); } ); // 递归向上找父节点,parent是p domUtils.breakParent( child, pre || tmp ); // 把p切开变为 node //去掉break后前一个多余的节点| ==>| var pre = child.previousSibling; domUtils.trimWhiteTextNode(pre); if(!pre.childNodes.length){ // 如果node前面的p是空的则删除,变为node domUtils.remove(pre); } next.appendChild(me.document.createElement('br')); // 在p添加一个br hadBreak = 1; // 切割元素问题只在第一次循环插入子节点时处理一次 }if(!div.firstChild && next && domUtils.isBlockElm(next)){ // 如果div变空,就是所有子节点都循环处理完了 range.setStart(next,0).collapse(true); // 把start container设置为插入节点后面的next占位节点(应该是p),offset=0(p里面的第一个子节点), break; } range.setEndAfter( child ).collapse(); //关键在这,此时第一个子节点已经插入到 里面, 已经分裂成node ,因此node.parentNode=body, container变为body,offset是node在body中的index=2。虽然是setendcontainer,但updatecollapse会更新startcontainer=endcontainer,所以实际上就是设置startcontainer=body,一旦第一个子节点插入成功,后续再插入时都是插入到之前插入的node的nextSibling节点p,offset是p在body里面的index,按offset找p节点,把node插入到p之前(insertBefore)。} inserthtml命令的函数代码就是把template插入编辑器网页,处理流程逻辑非常复杂深奥,涉及到很细小的细节比如空白换行符处理以及很细微的浏览器兼容性问题。经过长时间细致debug看数据研究源代码,最后发现插入子节点的过程原理如下: 假定回车换行,然后插入模板,把模板插入到当前行位置,这是最简单的情况,回车换行时编辑器会自动产生 。插入div的第一个子节点时是插入到 中,更准确地说是插入到p中的offset=0位置之前,也就是填充节点之前。 插入之后,把 节点分裂成两个 如下所示: node然后把空的 节点也删除,就变成了node ,之后再插入其它子节点时,是插入到中,offset位置是节点在中的index,也就是插入到body中 节点之前,每次插入一个node之后,node的nextSibling就是 节点占位元素。 这段代码是最复杂的插入程序,插入本来很简单,但编辑器插入非常复杂,因为可以在编辑器点击或选取任何位置区域做为插入位置,那么就复杂了,选取的区域是否要保留?是插入到选取区域的头部还是尾部?如果插入到一个元素的中间,那么元素要被分割成两个元素,而有些元素比如元素是不能分割的。因此插入处理流程逻辑以及细节非常复杂,还涉及到#text不可见文本节点,我们在开发应用时一般不会涉及到这么细节这么复杂的问题。
下面是insertHtml程序用到的几段函数代码: 根据start container判断当前选区range内容是否占位符: inFillChar : function(){ var start = this.startContainer; if(this.collapsed && start.nodeType == 3 && start.nodeValue.replace(new RegExp('^' + domUtils.fillChar),'').length + 1 == start.nodeValue.length //domUtils.fillChar是空格,这个表达式其实意思是找开头是否有空格,那么上述#text文本节点符合这个判断表达式 ){ return true; } return false; 判断给定的节点是否是一个“填充”节点: isFillChar:function (node,isInStart) { if(node.nodeType != 3) return false; var text = node.nodeValue; if(isInStart){ return new RegExp('^' + domUtils.fillChar).test(text) // 以空格开头 } return !text.replace(new RegExp(domUtils.fillChar,'g'), '').length // 在字符串找所有的空格去掉 对于上述#text文本节点, 其nodevalue是"",去掉空格之后长度为0,因此判断为填充节点,返回true。 这两个方法都是根据nodetype=3和nodevalue含空格来判断,有何区别?一个是判断range,一个是判断node。 如果占位元素是填充节点,就插入到填充节点之前,再删除填充节点,因为如果填充节点是,会换行。 将Range开始位置设置到node节点之前: setStartBefore:function (node) { return this.setStart(node.parentNode, domUtils.getNodeIndex(node)); //返回修改之后的range }, 检测节点node在父节点中的索引位置: getNodeIndex:function (node, ignoreTextNode) { var preNode = node, i = 0; while (preNode = preNode.previousSibling) { if (ignoreTextNode && preNode.nodeType == 3) { if(preNode.nodeType != preNode.nextSibling.nodeType ){ i++; } continue; } i++; } return i; }, 如果选中一段,再插入,又要保留选中的段,就比较复杂,可以插入到选中段的开头,也可以插入到选中段的尾部,也就是说选中的段可以在插入段的前面或后面。 编辑器range含start container和end container,就是选中的段的头尾节点。 collapse:function (toStart) { var me = this; if (toStart) { //插入到range的头部 me.endContainer = me.startContainer; me.endOffset = me.startOffset; } else { //插入到range的尾部 me.startContainer = me.endContainer; me.startOffset = me.endOffset; } me.collapsed = true; return me; 设置Range的开始容器节点和偏移量 * @method setStart * @remind 如果给定的节点是元素节点,那么offset指的是其子元素中索引为offset的元素, * 如果是文本节点,那么offset指的是其文本内容的第offset个字符 * @remind 如果提供的容器节点是一个不能包含子元素的节点, 则该选区的开始容器将被设置 * 为该节点的父节点, 此时, 其距离开始容器的偏移量也变成了该节点在其父节点 * 中的索引 setStart:function (node, offset) { // node是p,offset是里面子节点的offset,0是第一个子节点,但也可能是node在父元素中的offset return setEndPoint(true, node, offset, this); }, function setEndPoint(toStart, node, offset, range) { //如果node是自闭合标签要处理 if (node.nodeType == 1 && (dtd.$empty[node.tagName] || dtd.$nonChild[node.tagName])) { offset = domUtils.getNodeIndex(node) + (toStart ? 0 : 1); node = node.parentNode; } if (toStart) { range.startContainer = node; range.startOffset = offset; if (!range.endContainer) { range.collapse(true); } } else { range.endContainer = node; range.endOffset = offset; if (!range.startContainer) { range.collapse(false); } } updateCollapse(range); return range;
下面研究点击对话框“确定”按钮之后是如何处理的?如何能执行dialog.onok? debug看dialog iframe网页代码中“确定”按钮代码是: 确认 点击“确定”按钮是执行$EDITORUI[;edui223;]._onClick(event, this),这段代码是如何产生的?
dialog插件定义代码:// ui/dialog.js(function (){ Dialog = baidu.editor.ui.Dialog = function (options){ this.initOptions(utils.extend({ onok: function (){}, oncancel: function (){}, onclose: function (t, ok){ return ok ? this.onok() : this.oncancel(); }, },options)); this.initDialog(); }; Dialog.prototype = { initDialog: function (){ }, _hide: function (){ wrapNode.style.display = 'none'; }, open: function (){ this.render(); this.open(); }, close: function (ok){ this._hide(); } debug看工具按钮比如模板按钮是; 按钮html代码是由button对象的代码构造出来的: Button = baidu.editor.ui.Button = function (options){} Button.prototype = { getHtmlTpl: function (){ return '' + '' + '' 加debug看$$就是$EDITORUI['edui225']。
$EDITORUI['edui225']._onClick(event, this)代码是: _onClick: function (){ if (!this.isDisabled()) { this.fireEvent('click'); // ueditor自己的事件系统,对应editor.addListener,触发事件就是执行listener var EventBase = UE.EventBase = function () {}; EventBase.prototype = { // ueditor自己的逻辑事件系统 addListener:function (types, listener) { //把listener存储到listeners[]中 }, fireEvent:function () { // fireEvent就是到listeners[]中找listener执行 t = listeners[k].apply(this, arguments); r = t.apply(this, arguments); } } },
插件绑定了click事件; UE.plugins['template'] = function () { this.addListener("click", function (type, evt) { //在template操作过程中并没有执行这个handler var el = evt.target || evt.srcElement, range = this.selection.getRange(); var tnode = domUtils.findParent(el, function (node) { if (node.className && domUtils.hasClass(node, "ue_t")) { return node; } }, true); tnode && range.selectNode(tnode).shrinkBoundary().select(); });
工具栏按钮点击事件绑定: var btnCmds = ['undo', 'redo', 'formatmatch', 'bold', 'italic', 'underline', 'fontborder', 'touppercase', 'tolowercase', 'strikethrough', 'subscript', 'superscript', 'source', 'indent', 'outdent', 'blockquote', 'pasteplain', 'pagebreak', 'selectall', 'print','horizontal', 'removeformat', 'time', 'date', 'unlink', 'insertparagraphbeforetable', 'insertrow', 'insertcol', 'mergeright', 'mergedown', 'deleterow', 'deletecol', 'splittorows', 'splittocols', 'splittocells', 'mergecells', 'deletetable', 'drafts']; for (var i = 0, ci; ci = btnCmds[i++];) { editorui[ci] = function (cmd) { var ui = new editorui.Button({ onclick:function () { editor.execCommand(cmd); },
但“模板”按钮不在其中,有dialog的按钮是在这儿定义的: var dialogBtns = { noOk:['searchreplace', 'help', 'spechars', 'webapp','preview'], ok:['attachment', 'anchor', 'link', 'insertimage', 'map', 'gmap', 'insertframe', 'wordimage', 'insertvideo', 'insertframe', 'edittip', 'edittable', 'edittd', 'scrawl', 'template', 'music', 'background', 'charts'] }; var ui = new editorui.Button({ onclick:function () { if (dialog) { switch (cmd) { default: dialog.render(); UIBase.prototype = { render:function (holder) { holder.appendChild(el); //构造dialog el插入网页中占位元素(一个固定的浮动块)中 this.postRender(); postRender: function (){ this.addListener('show', function (){ me.modalMask.show(this.getDom().style.zIndex - 2); }); this.buttons[i].postRender(); postRender: function (){ this.Stateful_postRender(); Stateful_postRender: function (){ if (this.disabled && !this.hasState('disabled')) { this.addState('disabled'); this.setDisabled(this.disabled) }, } } dialog.open(); open: function (){ this.showAtCenter(); // 执行这个方法显示会话弹窗 showAtCenter: function (){ //设置定位 this._show(); _show: function (){ //dialog和编辑器两个平级浮动块要比z-index,要高过编辑器的zindxe this.editor.container.style.zIndex && (this.getDom().style.zIndex = this.editor.container.style.zIndex * 1 + 10); this.fireEvent('show'); baidu.editor.ui.uiUtils.getFixedLayer().style.zIndex = this.getDom().style.zIndex - 4; } } } } }
下面来分析一下编辑器初始化代码,因为在编辑器点击一下,然后点击模板按钮插入模板,是要插入到之前点击的位置,那么之前在编辑框内点击时编辑器如何获取位置或selection选取区域以及如何保存是个问题。 _setup: function (doc) { me.selection = new dom.Selection(doc); this.selection.getNative() this._initEvents(); _initEvents: function () { domUtils.on(doc, ['click', 'contextmenu', 'mousedown', 'keydown', 'keyup', 'keypress', 'mouseup', 'mouseover', 'mouseout', 'selectstart'], me._proxyDomEvent); domUtils.on(win, ['focus', 'blur'], me._proxyDomEvent); _proxyDomEvent: function (evt) { this.fireEvent(evt.type.replace(/^on/, ''), evt) } domUtils.on(doc, ['mouseup', 'keydown'], function (evt) { me._selectionChange(250, evt); _selectionChange: function (delay, evt) { me.fireEvent('selectionchange', !!evt); } } //编辑器不能为空内容 if (domUtils.isEmptyNode(me.body)) { me.body.innerHTML = ' ' + (browser.ie ? '' : '') + ' ';} //如果要求focus, 就把光标定位到内容开始 if (options.focus) { setTimeout(function () { me.focus(me.options.focusInEnd); //如果自动清除开着,就不需要做selectionchange; !me.options.autoClearinitialContent && me._selectionChange(); }, 0); } 从初始化事件代码看,绑定物理事件清清楚楚,对于mouseup事件,是按selectionchange事件去找handler执行,这个selectionchange事件有几十个handler都要执行,因为源码中有几十个editor.addListener('selectionchange',handler)语句,就不知道是哪个handler是处理range的,代码在哪里?要在89个函数中查找分析哪个函数是响应点击选取处理range的,难度很大,源代码非常复杂深奥高超。
经过不懈的努力,还好所幸最后终于发现有一个处理mouseup物理事件的handler代码处理了range: UE.plugins['table'] = function () { me.ready(function () { me.addListener("mouseup", mouseUpEvent); function mouseUpEvent(type, evt) { range = new dom.Range(me.document); range.setStart(target, 0).setCursor(false, true); me._selectionChange(250, evt); //变化选区 _selectionChange: function (delay, evt) { me.selection.cache(); // 获取选区保存到cache /**缓存当前选区的range和选区的开始节点 cache:function () { this.clear(); // 先清除历史range再保留最近的range this._cachedRange = this.getRange(); getRange:function () { var sel = me.getNative(); //由于已经清除历史range,此刻cache是空的,无cache数据可用,则执行下面代码 if ( sel && sel.rangeCount ) { //根据sel数据设置range数据最后返回range数据 var firstRange = sel.getRangeAt( 0 ); var lastRange = sel.getRangeAt( sel.rangeCount - 1 ); range.setStart( firstRange.startContainer, firstRange.startOffset ).setEnd( lastRange.endContainer, lastRange.endOffset ); if ( range.collapsed && domUtils.isBody( range.startContainer ) && !range.startOffset ) { optimze( range ); } return this._bakRange = range; }
因此,在编辑框内点击或选取时,是执行mouseUpEvent处理range,获取和保存当前点击位置或选取的区域,之后再插入模板时要获取插入位置,就是从cache取这个保存的range数据,插入模板时不可能再调用getRange()获取点击位置或选取区域,因为点击工具按钮之后,之前在编辑框内的点击或选取的光标状态已经改变不存在了。
下面看几个ueditor源码中的正则匹配表达式,学习一下正则: var re_tag = /]+)>)|(?:!--([\S|\s]*?)-->)|(?:([^\s\/]+)\s*((?:(?:"[^"]*")|(?:'[^']*')|[^"'])*)\/?>))/g, 匹配以下html标签写法: //比如 re_attr = /([\w\-:.]+)(?:(?:\s*=\s*(?:(?:"([^"]*)")|(?:'([^']*)')|([^\s>]+)))|(?=\s|$))/g; 匹配以下html标签属性写法:xxx:xxx-xxx.xxx = "xxx"xxx = 'xxx'xxx = xxxxxx 是不是晕? ?:可以忽略,就好看一点了。
源码中运用正则替换修改字符串的例子: htmlstr = htmlstr.replace(new RegExp('[\\r\\t\\n'+(ignoreBlank?'':' ')+']*]*)>[\\r\\t\\n'+(ignoreBlank?'':' ')+']*','g'), function(a,b){以 \n xxx \n 为例,匹配到两次,第一次匹配 \n ,第二次匹配 \n,调用function两次,a参数就是匹配的串,b参数是匹配的串里面的子匹配串(\\w+)文字符串,就是div,replace方法里面如果写function,function返回的就是替换内容。 return a.replace(new RegExp('^[\\r\\n'+(ignoreBlank?'':' ')+']+'),'').replace(new RegExp('[\\r\\n'+(ignoreBlank?'':' ')+']+$'),'');按pattern找到串之后去掉头尾的换行符返回做为替换内容,结果就是把源字符串中标签头尾的换行符去掉,返回修改之后的字符串。
本文到这里差不多就结束了,ueditor的工具按钮都以plugin插件方式定义,机制都一样,只是功能不同,本文不再一一分析,本文只以插入模板这个工具按钮为例进行了重点分析,其它插件应该都是类似的。 通过源代码分析学习,本人感觉ueditor是学习网页元素处理的顶峰,而前端框架是学习用对象编程技术实现组件机制和语义化表达式解析的顶峰,webuploader则是学习模块化编程的典范,只不过模块化编程现在已经被放弃了被webpack取代了。
本人水平有限,文中错误之处欢迎大家指正和交流。
|
CopyRight 2018-2019 实验室设备网 版权所有 |